查看原文
其他

从VS Code看优秀插件系统的设计思路

MoonWebTeam 腾讯云开发者 2023-09-28


👉导读

在目前流行的框架中,通常都会采用插件来定制、扩展系统的能力。其实插件系统在软件架构中有一个更专业的术语:微内核架构。本文将详细介绍微内核架构的原理、优势、分类、同时也会对优秀的软件案例进行分析,最后结合笔者当前负责的中型前端项目,分享微内核架构在实际项目的实践。通过本文您将了解什么是插件系统,如何设计插件系统,以及怎么更优雅地实现插件系统,欢迎阅读。

👉目录

1 微内核架构概述2 为什么需要微内核架构3 怎么实现插件4 VS Code 插件设计的优秀实践5 H5 云游插件系统的实践及改进6 总结



01



微内核架构概述

什么是微内核架构?如果换一个名字,或许大家就很熟悉了,那就是插件系统。我们实际工作生活中接触到的大型软件,大部分都拥有插件系统。


比如开发工具 VS Code,拥有一个强大的插件系统,可以为 VS Code 添加新的语法支持,新的主题,甚至添加 VS Code 原本不支持的能力,通过社区贡献的2万多个插件,VS Code 的能力变得所向披靡。


相较之下,没有插件系统的 Notepad 之类的软件,功能就很单一,也没有任何扩展的可能性。


拥有强大插件系统的还有浏览器 Chrome,前端的构建工具 Webpack、Rollup 等。几乎所有大型的软件,都拥有一个插件系统。


微内核架构中,软件的核心部分通常被称为微内核,或者宿主程序。微内核提供了一些标准接口和扩展点,允许插件以某种方式与其进行交互。插件则是独立的模块,可以独立开发并在宿主应用程序中加载和执行。


实际开发过程中,是不是一定要引入微内核架构呢,答案是否定的,具体需要结合软件系统诉求,看是否有对自身进行定制或者扩展的需求,是否能解决当前软件系统面临的问题。


接下来将介绍使用微内核架构解决了什么问题,带来了什么好处。




02



为什么需要微内核架构

那微内核架构到底解决了什么软件问题?


主要体现在两个方面:对软件本身现有的能力进行定制化;为软件提供全新的能力。


这样的好处就在于,它提供了一套开放的接口,可以方便第三方来参与软件的定制和扩展,让大型软件的能力得以灵活地扩展。


其实微内核架构的实现并没有统一的标准,它的架构示意如下图所示:



微内核架构的核心代码保持逻辑单一,只负责程序的启动销毁,功能模块的加载、执行、卸载。软件的功能叠加由不同的插件来实现,并挂载到核心上实现功能的扩展。


这样允许软件的功能可以被动态地扩展和定制,在增强现有软件的功能或添加新功能的同时,无需修改核心程序代码。


可以看一下跟微内核完全相反的另一种架构设计:


把一个软件和它的各种功能都做在一起,内核功能与各个功能模块耦合在一起,如下图:



这种场景下,当我们需要定制某个功能时,我们需要直接修改软件的内核逻辑,显然不符合软件设计的开闭原则,不仅增加了软件开发的维护难度,同时也大大提升软件扩展的难度,从而使软件本身不具备有良好的扩展性。


如果将宏内核的架构改为微内核架构:



每个功能都成为插件,独立维护开发,不与内核耦合。每个插件需要定制,可以独立修改、发布,不影响其他插件及内核,同时也可以添加新的插件。相比宏内核,软件的维护难度大大降低,同时只要遵循插件的接口定义,就可以为软件开发新的功能,降低了软件扩展的难度,使得软件获得了很好的灵活性和扩展性。


总结下来,微内核架构有如下的优势:


灵活性和可扩展性: 插件系统允许软件在运行时加载和卸载插件,从而实现灵活的功能扩展和定制化。通过插件,可以根据用户需求添加、移除或替换特定功能,而不需要修改核心代码,使得软件更易于扩展,易于适应变化的需求。
代码重用和模块化: 插件可以看作是独立的模块,它们可以在不同的应用中重复使用。这种模块化的设计使得代码更加可维护,减少了代码冗余,提高了代码重用率。

社区参与和共享: 插件系统鼓励社区的参与和贡献,第三方开发者可以开发自己的插件并与软件进行集成。这样,软件的功能得到了大大丰富,社区成员可以共享自己的扩展,促进了软件生态系统的发展。

解耦合和维护性: 插件系统帮助将软件的功能划分为独立的部分,降低了模块之间的耦合度。这使得软件更易于维护,当需要修改或升级某个功能时,只需关注相应的插件而不会影响整个系统。

性能和资源优化: 插件的动态加载和卸载使得软件可以根据需要来选择加载特定的功能,从而节约了内存和计算资源,提高了软件的性能。

定制化和个性化: 插件系统允许用户根据自己的需求来定制软件的功能和外观。用户可以选择安装和启用特定的插件,以满足个人喜好和工作流程。


总体来说,微内核架构为软件提供了灵活性、可扩展性和定制化的能力,使得软件更加强大和适应性更强。它是构建功能丰富、易于维护和具有强大生态系统软件的关键要素之一。




03



怎么实现插件

前面讲到微内核架构的各种优势,本小节会介绍微内核架构的具体实现方式,及知名软件的微内核架构分析。通过这些案例深入浅出,我们可以将微内核架构很好地运用到自己的项目当中。


微内核架构尽管实现的方式不尽相同,但总的来说都包含下面几个步骤:


定义插件接口:首先,需要定义插件与主程序之间的接口,包括插件的初始化方法、执行方法、事件监听等。这样可以确保插件与主程序之间的交互是规范的。

插件的加载方式:确定插件的加载形式,比如通过 npm 包,通过文件,通过 git 仓库等等,好的插件的组织形式使整个系统足够灵活。设计好插件的加载时机,比如惰性加载,按依赖加载等,好的加载时机把控,可以让大型系统的性能得到提升。

插件注册和管理:主程序需要提供插件注册和管理的功能,用于管理已加载的插件列表。当插件加载完成后,将其注册到主程序中,这样主程序就可以调用插件的能力。

事件通信机制:主程序和插件之间需要建立事件通信机制,以便在需要的时候进行交互。可以使用自定义事件、发布订阅模式或观察者模式等方式来实现事件的监听和触发。

插件配置:可以为插件提供一些配置选项,使得插件的行为可以根据用户需求进行定制化。

安全性考虑:插件系统涉及动态加载代码,因此安全性是一个重要考虑因素。确保只加载受信任的插件,并对插件的代码进行安全性检查,以防止潜在的恶意代码注入。


业界关于插件设计模式有很多种,但是经过归纳总结,我们认为最常用的主要是以下三种插件模式:管道式、洋葱式事件式,其中应用最为广泛的是事件式插件,以下也将分别从“特点”、“应用”两个方面介绍下这三种插件模式。


   3.1 管道式插件


管道式插件(Pipeline Plugin)是常用的插件设计模式之一。它的主要目标是将处理流程分解为一系列独立的步骤,并允许开发者通过插件来扩展或修改这些步骤,从而实现更灵活和可维护的代码。



如上图所示,在管道式插件中,处理流程被表示为一条管道,数据从管道的一端输入,经过一系列步骤进行处理,最终在管道的另一端输出。每个处理步骤都由一个插件来实现,该插件负责执行特定的任务,并将处理后的数据传递给下一个插件。


   3.1.1 管道式插件的特点


管道式插件的优点包括:


▶︎ 解耦性强,管道的每个环节之间相互独立,只处理特定的问题,可单独开发、测试和维护。


▶︎ 在输入输出标准化的情况,可以灵活组合插件,根据需求动态改变管道结构,实现数据处理流程的定制化和扩展性。


举例:Linux 的管道,我们可以组合不同的管道命令对数据进行灵活处理,以下是 cat 命令与其他命令的组合。


# 输出文本file.txt中带“keyword”字符的行cat file.txt | grep "keyword"# 输出 file.txt 文件中的行数、单词数和字符数。cat file.txt | wc# 输出file.txt 文件中的第一列cat file.txt | awk '{print $1}'

▶︎ 通过管道架构,可以方便进行数据缓存、异步处理和并发等优化,提高处理效率和系统性能。


举例:数据分析平台(如灯塔)通过管道架构设计,利用中间表和缓存提高大数据的查询速度,同时通过各个中间表的拆解,降低了查询 SQL 语句的复杂性。



管道式插件的局限性包括:


  • 管道的设计需要考虑插件之间的数据密切性和执行顺序,可能会增加开发难度和设计复杂度。

  • 如果不合理的设计管道流程,可能会导致数据的不完整性和不准确性,对系统造成影响。


举例:上述的数据分析平台中,某个中间表出库计算任务失败,就有可能导致后面的中间表的计算任务全部失败,最终导致数据查询不可用。


   3.1.2 管道式插件的应用


管道式插件在许多领域都有应用,例如:


数据处理管道:在数据处理中,可以使用管道式插件来处理数据的转换、过滤、验证等任务,确保数据在不同步骤中按照预期进行处理。

自动化任务执行:自动化构建、自动化部署等任务的执行,比如 CI/CD 流水线,再比如云服务部署。

前端构建工具:在前端构建工具中,如 Gulp,管道式插件被广泛用于处理和转换源代码,例如编译、压缩、合并文件等。


以前端工具 Gulp 举例,以下是 Gulp 的架构图:


图片来源(前端自动化构建之 Gulp - 珲少的个人空间 - OSCHINA)


Gulp 任务流配置示例:


/*创建一个名为css的任务将src目录下的所有的less样式文件转成css,随后压缩并合并成一个名为app.css的文件,对这个文件加上md5版本签名,生成到build/css路径下,并生成映射文件放到src/css*/gulp.task('css', ['cleanWatchBuild', 'txtCopy'], function() { return gulp.src(['src/css/**/*.less']) .pipe(less()) .pipe(minifyCss()) .pipe(concat('app.css')) .pipe(rev()) .pipe(gulp.dest('build/css')) .pipe(rev.manifest({ base: 'src/**', merge: true })) .pipe(gulp.dest("src/css"));});

综上,管道式插件是一种强大的设计模式,可以使代码更加灵活、可维护和可扩展,同时提供了一种模块化的方式来组织和处理复杂的任务。


   3.2 洋葱式插件


洋葱式插件(Onion Architecture Plugin)也是常用的一类插件设计模式,它是从洋葱架构(Onion Architecture)演化而来的。


洋葱架构是一种用于构建可维护、灵活且可测试的应用程序的软件架构模式。在洋葱架构中,应用程序的核心逻辑位于内部,而外部依赖(如数据库、UI 等)则位于外部。洋葱架构通过层层包裹的方式来表示不同的关注点,类似于洋葱的结构,因此得名。



洋葱式插件将洋葱架构与插件系统相结合,以实现可插拔的、可扩展的应用程序。在这种模式下,插件可以被动态地加载和卸载,而不会影响应用程序的核心逻辑,从而使得应用程序更具灵活性和可维护性。


   3.2.1 洋葱式插件的特点


洋葱式插件的主要优点包括:


洋葱架构的层次分明, 洋葱式插件保留了洋葱架构的内部核心和外部依赖的层次结构。插件通常被视为外部依赖,而宿主应用程序的核心逻辑位于内部。


具备良好的重用性,洋葱架构中的各个层次和组件都可以独立地被重复利用,可以在不同的项目和场景中进行复用,提高了代码的可重用性。


举例:比如 Koa 中很多中间件具备良好的复用性(如 koa-session),多个项目均可以引入使用.


洋葱式插件允许插件在请求处理过程中先后执行,可以按需添加或删除插件,并且每个插件可以根据需要决定是否继续执行或终止执行,这使得洋葱式插件非常适合承当服务拦截器的角色。


与管道式插件相比,洋葱式插件对数据干涉的时机更加完备,不仅仅可以对自身的数据输入环节进行干涉和处理,在数据输出环节还能对其他插件的输出进行干涉和处理。


洋葱式插件的局限性包括:


相比管道式插件复杂性更高,洋葱式插件模式需要插件之间的协作和数据传递,即处理输入流和处理输出流,在处理复杂逻辑时可能导致代码变得复杂难以理解。


洋葱架构中的层次嵌套可能会增加函数调用的次数和层次,进而导致一定的性能损耗。


   3.2.2 洋葱式插件的应用


洋葱式插件模式在服务中间件中广泛应用:


洋葱式插件对数据流具备灵活和高权限的处理能力(能在输入输出两个环节来决定是否中断还是继续执行),非常符合服务中间件的使用场景。


在前端领域,除了 Koa、Express 使用了洋葱式插件模式外,一些知名 Nodejs 框架也使用了洋葱式插件模式,比如 Midway、Uni-request。


以 Koa 为例,洋葱式插件运行阶段会经过3个环节:



  • 任务注册


Koa 通过 use 方法进行任务注册。


  • 任务编排


任务编排分为前置处理、核心逻辑、后置处理器。


图片来源于《如何更好地理解中间件和洋葱模型》


  • 任务调度


Koa 中的任务调度由 Koa-compose 来统一负责。


图片来源于《如何更好地理解中间件和洋葱模型》

以上是执行第一个中间件,触发 dispatch(0),第一个中间件执行 next()后,就会触发 dispatch(1),进入第二个中间件,以此类推。

   3.3 事件式插件


事件式插件(Event-based Plugin)是插件设计模式中最灵活的一种,它基于事件驱动编程。在事件式插件中,主程序(或宿主应用程序)通过触发事件来通知插件执行相应的操作。插件系统允许插件注册特定事件的监听器,并在相应事件被触发时执行相应的功能。



   3.3.1 事件式插件的特点


事件式插件的主要优点包括:


灵活度高,应用场景广。


运行方式多样,事件类型多,十分灵活,能适应于各种场景。


如 Webpack 当中,其通过 Tapable 实现了一种发布订阅者模式的插件机制,提供同步/异步钩子,串行/并行钩子,按照执行类型分为瀑布/保险/循环钩子,并且可以进行灵活组合来满足 Webpack 编译打包的所有功能扩展需求。


图片来源于《Tapable,看这一篇就够了》


执行时机异步化,提升整体性能。


因为事件式插件是基于发布订阅实现的,执行的时机异步化,非阻塞式地执行代码,有利于提升整体的性能。


VS Code 在插件系统中,应对几十个插件的应用,也不会有太大的性能问题,不仅仅是因为事件触发之后才会初始化插件,也是得益于事件式插件带有的益处。


可插拔式的设计。


事件式插件还有一个重要的特点,可插拔式的设计,使插件在添加或删除的时候,都不会影响主流程的执行。


如 Chrome 浏览器支持使用事件式插件的方式来扩展其功能,但是不会影响原有的浏览器功能的执行。


事件式插件的主要问题包括:


事件式插件虽然在插件注册和执行上具备非常大的灵活性,但是相应架构设计上会比管道式和洋葱式更为复杂,从而更容易引入未知问题。


事件式插件系统完全可以覆盖管道式插件系统的职能(使用串行的事件模式达到管道的效果),但是如果明确一个管道式的需求,则更建议使用管道式插件系统,因为管道式插件系统更为简单。


   3.3.2 事件式插件的应用


事件式插件在前端领域有着广泛的应用,比如构建工具 Webpack,以及知名代码编辑器 VS Code,这里以 VS Code 为例来讲述一下事件式插件的运行原理。


这里主要研究客户端的插件系统运行流程,web 端类似。


整体的运行流程如下:



▶︎ 初始化插件系统


/**vscode/src/vs/workbench/services/extensions/electron-sandbox/electronExtensionService.ts*/export abstract class ElectronExtensionService extends AbstractExtensionService implements IExtensionService { (@ILifecycleService lifecycleService: ILifecycleService, ) {// 初始化插件系统服务 lifecycleService.when(LifecyclePhase.Ready).then(() => {// reschedule to ensure this runs after restoring viewlets, panels, and editors runWhenIdle(() => {this._initialize(); }, 50 /*max delay*/); }); }}

在客户端插件服务初始化时,所有的 service 被设置之后,会将生命周期转为 Ready 阶段,然后进行服务的初始化。


▶︎ 扫描插件


在插件系统初始化的时候,通过 CachedExtensionScanner 模块扫描已经安装的插件,主要是解析出以下信息:


  • 插件的名称;

  • 插件的版本;

  • 入口文件;

  • 与插件主流程相关的配置:

  • “activationEvents”,绑定激活事件,当指令被触发时,事件将会被激活;

  • “commands”,注册指令;

  • “explorer/context”,设置菜单的指令;

  • “commandPalette”,设置命令面板的指令。


{ "name": "ts2plantuml", "version": "1.0.4", "description": "", "main": "./out/extension.js", "activationEvents": [ "onCommand:ts2plantuml.explorer.preview" ], "contributes": { "commands": [ { "command": "ts2plantuml.explorer.preview", "title": "Preview Class Diagram", "category": "TS2PLANTUML" } ], "menus": { "explorer/context": [ { "command": "ts2plantuml.explorer.preview", "when": "resourceLangId == typescript" } ], "commandPalette": [ { "command": "ts2plantuml.explorer.preview", "when": "resourceLangId == typescript" } ] } }

▶︎ 注册插件


根据上诉扫描出来的配置,通过 ExtensionDescriptionRegistry 模块,对插件进行注册,首先通过 commands 字段对指令进行注册,同时声明激活插件的事件,以及各操作路径可以触发的指令。


▶︎ 监听激活事件


通过监听激活事件,来激活插件,如上诉的配置中,当 ts2plantuml.explorer.preview 指令触发时,激活对应的插件。


// vscode/src/vs/workbench/api/common/extHostExtensionService.tsexport abstract class AbstractExtHostExtensionService extends Disposable implements ExtHostExtensionServiceShape { private _startExtensionHost(): Promise<void> { if (this._started) { throw new Error(`Extension host is already started!`); } this._started = true; return this._readyToStartExtensionHost.wait() .then(() => this._readyToRunExtensions.open()) // 监听激活事件 .then(() => this._handleEagerExtensions()) .then(() => { // 激活插件 this._eagerExtensionsActivated.open(); this._logService.info(`Eager extensions activated`); }); }}

通过触发的激活事件,激活插件,同时将激活的事件做一个缓存,防止重复执行,这里即是监听激活事件里面的逻辑。


// vscode/src/vs/workbench/api/common/extHostExtensionActivator.tsexport class ExtensionsActivator implements IDisposable { public async activateByEvent(activationEvent: string, startup: boolean): Promise<void> { if (this._alreadyActivatedEvents[activationEvent]) { return; } const activateExtensions = this._registry.getExtensionDescriptionsForActivationEvent(activationEvent); await this._activateExtensions(activateExtensions.map(e => ({ id: e.identifier, reason: { startup, extensionId: e.identifier, activationEvent } }))); this._alreadyActivatedEvents[activationEvent] = true; }}

▶︎ 加载插件,并执行


最后通过 AbstractExtHostExtensionService 模块加载插件,这里加载插件时,会对 require 进行拦截,对 VS Code 进行代理,从而保证安全的执行环境,最后执行插件入口暴露出的 activate 函数进行激活插件。




04



VS Code 插件设计的优秀实践

前文已经以 VS Code 为例子介绍了事件式插件的运行原理,实际上, VS Code 的插件系统设计还有很多值得我们学习,比如安全性的考量,比如代码解耦,可维护性的考量,比如如何写出优雅的代码的考量等等。


实现一个基于事件的插件系统并不难。但一个优秀的实现比一个粗糙的实现要多了上面所说的多方面的细心设计。下面我们从两个方面来进一步研究下 VS Code 的设计。


   4.1 通过沙箱实现隔离性


插件通常是由第三方开发者开发,项目的质量参差不齐,有性能稳定,安全可靠的插件,也有能够刚好运行起来,暗藏大量 bug 的插件。


一旦将存在安全隐患的插件引入主程序里,如果不加以防护,可能连带主程序一起崩溃。


作为有3万+插件的 VS Code 架构上肯定不允许上述的情况发生,不然 VS Code 的口碑就会崩塌。事实上,我们日常开发中几乎没见过 VS Code 崩溃过。


那 VS Code 如何保证插件的隔离性的?答案是:通过执行环境隔离。


VS Code 的运行插件第一步是新建一个 Webworker 来运行插件的逻辑,这样插件的逻辑完全无法直接影响的主程序的逻辑。VS Code 是通过vs/workbench/services/extensions/browser/webWorkerExtensionHostStarter.ts 中的 start 方法创建一个新的 Webworker 作为插件的执行环境。


const url = getWorkerBootstrapUrl(require.toUrl('../worker/extensionHostWorkerMain.js'), 'WorkerExtensionHost');const worker = new Worker(url, { name: 'WorkerExtensionHost' });

那主程序和插件完全隔离了,怎么互调?答案是:通过消息发送。


插件初始化后会与主程序建议消息通道,如下代码:


start(){ ..... const protocol: IMessagePassingProtocol = { onMessage: emitter.event, send: vsbuf => { const data = vsbuf.buffer.buffer.slice(vsbuf.buffer.byteOffset, vsbuf.buffer.byteOffset + vsbuf.buffer.byteLength); worker.postMessage(data, [data]); } } return protocol;}

VS Code 定义了一个标准的通讯协议结构:IMessagePassingProtocol。


通过 Webworker 的 onMessage 和 postMessage 来维护一个消息通道,实现


IMessagePassingProtocol 接口分别定义了 send 方法和 onMessage 方法用于向 worker 发送消息和从 worker 接收消息。如下代码所示


export interface IMessagePassingProtocol { send(buffer: VSBuffer): void; onMessage: Event;}

IMessagePassingProtocol 进一步封装,创建一个 RPCProtocol 通道。主进程和 worker 进程之间可以通过 RPCProtocol 进行模块调用。


   4.2 通过 proxy 进行通讯


解决了插件隔离性问题,我再看下插件系统的通讯机制。一般插件系统是如何实现插件和主程序的通讯的呢?


通常我们基于事件都会这么定义:


接收响应事件:


eventSystem.on('xxx事件', () => { // 事件处理逻辑})

发送事件:


eventSystem.send('xxx事件', 参数1, 参数2 ...)

虽然能用,但实际上很不优雅,也不直观。VS Code 针对这种场景,巧妙地运用了 JS 的 proxy 特性。


举个例子,当主程序要调用插件的方法时,是这么做的:


extensionProxy.$doSomeStuff(arg1, arg2);

非常简洁,也非常优雅直观。一个跨进程的调用实现得跟本地调用没有区别,完全看不出是一个事件的发送,这一切都归功于 RPCProtocol。


RPCProtocol 的实现原理是利用 Proxy 代理将方法的调用,转换成远程消息的发送。如下代码所示:


export class RPCProtocol extends Disposable implements IRPCProtocol { ((protocol: IMessagePassingProtocol, ...){ super(); this._protocol = protocol; } .... //创建一个proxy,将对本地对象方法的调用转成一个远程调用 private _createProxy(rpcId: number): T { let handler = { get: (target: any, name: PropertyKey) => { //如果方法名以$开头,则转换成远程调用 if (typeof name === 'string' && !target[name] && name.charCodeAt(0) === CharCode.DollarSign) { target[name] = (...myArgs: any[]) => { //发送远程消息 return this._remoteCall(rpcId, name, myArgs); }; } return target[name]; } }; return new Proxy(Object.create(null), handler); } //拼装远程消息,通过IMessagePassingProtocol发出 private _remoteCall(rpcId: number, methodName: string, args: any[]): Promise { const msg = MessageIO.serializeRequest(..., rpcId, methodName, ....); this._protocol.send(msg); }}

接下来笔者将结合 H5 云游项目,讲讲我们在具体项目中的应用实践。




05



H5云游插件系统的实践及改进

H5云游项目在架构设计方面,除了运用 DDD 进行分层和实体抽象的架构设计,同时在 SDK wrapper 模块也重度运用了微内核的架构思想。


当前云游项目的微内核设计,已经具备一定规模,有值得借鉴的优点,同时也有需要改进提升的地方。通过本次对微内核架构理论的深度分析,以及对知名大型项目微内核架构的设计思路,我们思考了后续可以对云游微内核架构改进提升的结合点。


   5.1 当前云游插件系统架构


首先我们先回顾一下云游现在的微内核架构。云游的微内核架构主要使用在 SDK wrapper 系统上。SDK wrapper 主要的功能是封装云游 SDK 的能力,使用的是基于事件驱动的双向插件微内核架构。主要做的的有两方面:


封装:将云游 SDK 的调用复杂性进行封装,对外提供简单一致的接口,即微内核。

扩展:为云游 SDK 添加扩展能力,即插件系统,支持基于 SDK 的基础能力进一步扩展,如 SDK 状态管理,环境适配,上报,错误提示,全屏等。


目前 SDK wrapper 的初具规模的插件体系如下:


云游 SDK wrapper 的架构图:(先锋和架平代表两个云游实现平台)


其中微内核分为三个功能模块:


   5.1.1 多内核管理模块


SDK wrapper 出于支持多个云游 SDK的考虑,采用了多内核设计架构。


通过内核管理模块,可以抹平内核对外的接口调用和事件通讯的差异性。


这种架构大大简化了上层业务设计的复杂度,上层业务运行在一个抽象的内核上,对底层内核的实际实现无感知。


同时这种架构既提升了后续切换其他内核的可能性,也降低了切换的成本。


下面的 BaseSDKAdapter 实现了内核的抽象层,新增内核只需继承并选择性实现差异的接口即可接入云游项目,对上层业务的耦合度为 0。


export class BaseSDKAdapter { ... /** 获取SDK状态 */ public getSDKState() { return this.sdkState; } /** SDK预加载 */ public async preload() { // 子类继承实现 } /** 初始化Adapter配置 */ public async init(uiConfig: UIInitConfig, sdkConfig: InitConfig): Promise<void> { } ... /** 开始播放云游视频流 */ public async play(): Promise<void> { // 子类继承实现 } /** 暂停云游视频流 */ public pause(): void { // 子类继承实现 } /** 设置音效 */ public setVolume(_volume: number) { // 子类继承实现 } /** 设置码率 */ public setBitrate(_minBitrate: number, _maxBitrate: number) { // 子类继承实现 } ...}

下面代码摘自 先锋 SDK 内核适配器实现:


export class GameMatrixSDKAdapter extends BaseSDKAdapter { private cloudGame: CloudGame | null = null; /** 预加载 */ public async preload() { const ret = await sdkPreloaderEntity.preload(); this.afterPreload(); return ret; } ... /** 返回 SDK 实体 */ public getRaw(): any { return this.cloudGame; } /** 获取当前设备 id 需要在 device ready 之后才能获取到 */ public getDeviceId() { return this.cloudGame?.getDeviceId(); } /** 获取云游的video节点 */ public getCloudGameVideo() { return this.cloudGame?.videoManager.video; } ...}

多内核的架构,本质也是一种插件机制,通过定义好抽象的接口层,来定义插件的规范,通过实现这个规范,让不同的内核可以接入到当前项目。这里内核的实例化会由 SDKManager 根据当前云游下发的配置来实例化某个指定内核。


   5.1.2 支持事件转发的事件管理


SDK wrapper 的微内核架构是通过事件来实现内核与插件之间的通讯的。但由于内核事件来源并不是单一的,并且同类事件之间是有差异,所以架构上采用了事件转发的机制,来统一事件发送的协议。


同时由于事件的来源分为两种,一种是 SDK 事件,另一种是扩展事件,所以根据不同的事件来源,做了事件策略模式的设计,隐藏了事件的差异。


通过上面的解决方案,实现了插件对事件的差异和来源无感知的效果,使插件的实现更为简单,可维护。


下图展示了 SDK wrapper 的事件转发架构,将先锋内核事件、架平内核事件、YYB SDK 扩展事件统一在事件转发器进行适配,其中红色箭头表示发送事件,蓝色箭头表示接收事件。



下面是事件转发器基类的代码摘要:


class BaseEventTransformer {/** 事件注册 */public event(eventType: SDKWrapperEvent, callback: any) { }/** 事件注册(仅一次) */public eventOnce(eventType: SDKWrapperEvent, callback: any) { }/** 事件触发 */public emit(eventType: SDKWrapperEvent, data?: any) { }/** 事件移除 */public removeEvent(eventType: SDKWrapperEvent, callback: EventCallback) { }}

下面是架平内核的事件适配器代码摘要:


class JiapingEventAdapter extends BaseEventTransformer {}

通过这种架构,插件内的事件接收和发送,无需关注事件的来源和差异,只需处理一套事件系统。


   5.1.3 支持多范式的插件管理(已废弃)


虽然这个能力已经被废弃了,但还是值得提一下。


我们编程的范式,有函数式编程,有面向对象编程,每种编程都有其适用的场景。云游 SDK wrapper 为了能向下简化简单插件的开发,向上支持复杂插件的开发,提出了支持多范式的插件编写方式。


简单的插件可以通过函数式编程的方式实现,复杂的插件通过面向对象的方式实现。


这样的做法有利有弊,好处是,可以按需使用范式来开发插件,减少不必要的冗余实现,简单的插件一个函数就能完成,复杂的插件通过面向对象来继承实现。


但不好的地方是,插件的编写方式需要维护两套机制,风格不统一。


基于统一开发范式和风格的设计考虑,在重构过程中取消了这种支持多范式的插件管理,改为统一使用面向对象的方式编写插件。


下面回顾一下多范式的插件管理的函数式插件和对象式插件是怎么实现的,并且是怎么被插件系统加载的。


函数式插件:


export function MySimplePlugin(config: PluginConfig) { // 初始化插件 // 注册监听事件}

对象式插件:


export function MyComplexPlugin extends BasePlugin { public register(config: PluginConfig) { } private init() { } ...}

插件的加载及如何统一管理不同范式的插件:


class PluginManager { ... loadPlugin(Plugin: FuntionalPlugin | typeof BasePlugin) {let PluginClass: BasePlugin | null = null;// 判断 Plugin 是否继承自 BasePluginif (Plugin.prototype instanceof BasePlugin) { PluginClass = Plugin; } else {// 如果 Plugin 是函数式,使用匿名类将函数式插件适配为对象式插件 PluginClass = class extends BasePlugin { public register(config: PluginConfig) { Plugin(config); } } }// 进行插件的注册和初始化 } ...}

   5.2 云游插件系统改进思路


通过对云游插件系统的架构设计回顾,我们整个设计上已经相对完备,从插件的配置,插件的生命周期管理,插件的接口设计,插件和内核的通讯机制,都已经得到合理的设计。


但通过我们对微内核架构的深入,以及对现有的优秀的软件的架构设计的学习,我们还是发现有不少可以提升设计的可维护性,提升代码的优雅型的方法。


   5.2.1 插件的类别管理


当前云游的插件,缺乏对插件类别的管理。所有的插件都一视同仁,给与同样的权限,同样的接口。很显然,这种设计上满足目前的需求,但如果考虑到后续的扩展,则是不够灵活的,也不够合理的。


这么说可能有点抽象了,什么是插件的类别?


举个例子,VS Code 有 3 万多个插件。那这些插件不可能干的是同一类事情,有的插件是用来修改 VS Code 的主题的,有的插件是用来跟外部工具互动的,有的插件是用来添加新的语言支持的。


可能你会说,我为什么就不能设计一个相同的模式,把所有这些事都干了呢?答案是可以的,但这样的模式会非常臃肿,非常冗余,并且是完全不符合开闭原则的。比如一个主题类的插件是不需要关注跟编程语言语法支持相关的接口和事件的,一个跟外部工具交互的插件,不太可能会关注编辑器的样式。


这样不同的行为和关注点就为插件划分了类别。


不同的插件类别有不同的运作方式,也有不同的权限,生命周期也不同。


所以云游可以参考 VS Code 对插件的类别划分,进一步增加插件分类,让插件的运作机制和权限得到细化,提升代码的可维护性,也更符合开闭原则。


下面给出一种插件类别的改进思路。


首先插件的定义,需要包含类型的信息,如云游现有插件的三大分类,分别定义三个插件抽象基类,这三类插件的权限逐个递减:


SDKExtendPlugin:负责对云游 SDK 最底层的能力进行定制,需要跟 SDK 内核进行互调,需要知道内核的实现细节,所以给予最高的访问权限,拥有对 SDK 内部事件的所有读写权限。

类似这样的插件,可以是为 SDK 提供 H265 解码适配的插件等。

PlatformAdaptPlugin: 这类插件通常只需要关注 SDK 发送的底层事件,比如实时的 WebRTC 通讯事件,从而可以用于展示通讯状态,或者进行数据分析,上报等。这类插件需要完全的 SDK 事件接收权限,但不允许修改 SDK 内部的状态,所以被禁用了 SDK 的事件发送权限。

类似的插件有 WebRTC 状态统计插件等。

BusinessPlugin:这类插件主要是需要依赖 SDK 的时序事件来完成业务侧的逻辑,对 SDK 的底层逻辑细节不关注,所以只对其开放 SDK 部分高级事件的订阅能力,是三类插件中权限最低,对 SDK 依赖最小的插件。

类似的插件有云游加载界面插件等。


// 用于扩展 SDK 的底层能力,拥有 SDK 内部事件的读写权限,支持调用内核的所有函数export class SDKExtendPlugin extends BasePlugin {}// 跨平台适配插件,可以订阅 SDK 所有事件,不能操作 SDK,支持调用部分与平台操作相关的内核接口export class PlatformAdaptPlugin extends BasePlugin {}// 业务侧插件,只能订阅 SDK 的状态流转事件,不能操作 SDK,仅支持少部分内核调用接口export class BusinessPlugin extends BasePlugin {}

每一类插件的加载管理分开,并且生命周期分开管理,注入的内核对象进行权限约束。


class PluginManager { private sdkExtendPlugins: SDKExtendPlugin[] = []; private platformAdaptPlugins: PlatformAdaptPlugin[] = []; private businessPlugins: BusinessPlugin[] = []; ... loadPlugin(Plugin: typeof BasePlugin) { if (isExtendClass(Plugin, SDKExtendPlugin) { // 进行 sdk extend plugin 的注册和初始化 } if (isExtendClass(Plugin, PlatformAdaptPlugin) { // 进行 platform adapt plugin 的注册和初始化 } if (isExtendClass(Plugin, BusinessPlugin) { // 进行 business plugin 的注册和初始化 } } ...}

   5.2.2 内核与插件的开闭原则


上面关于插件的类别管理讲到了对内核接口的权限管理。那为何需要对内核接口进行权限管理,怎么实现权限管理,本小节将进行展开讲述。


首先是,为什么要对内核接口进行权限管理?我们先不急着解答。


先看看一个假设的插件是怎么初始化和调用内核的能力的:


class Plugin { // 对内核的引用 private core: Core; /** * @param core 内核对象 * @param config 插件配置信息 **/ init(core: Core, config: Config) { // 保存内核的引用 this.core = core } // 某个插件方法 doSomething() { // 将内核销毁的方法 core.destroy(); }}

上面的例子,如果我们没有对内核 core 进行限制,我们可以在插件里直接对内核进行销毁,这肯定不是一个合理的设计。


特别是像 VS Code 这种软件,它的插件都是社区贡献的,如果接口权限不做限制,那 VS Code 的口碑应该已经不存在了。


实际上 VS Code 在保障插件的稳定性做了很多工作,比如权限管理,运行沙箱等。


考虑到云游是一个 web 项目,并且插件都是项目内部维护。我们暂时还不需要使用运行沙箱,但对内核的权限管理还是很有必要的。


为什么呢?通过对内核的权限管理,我们可以为每种类型的插件提供一份不同权限的内核接口,确保我们在开发插件的时候,不会错误地编写出超越插件职责的代码。当我们编写时,遇到超越职责的代码,我们就会思考,我们的插件归类是否合理,职责是否单一,从而约束我们编写出可维护性更高的代码。


那如何改进上面的例子,让它支持内核接口的约束呢?


class Plugin { // 对内核的引用 private core: Core; /** * @param core 内核对象 * @param config 插件配置信息 **/ init(core: Core, config: Config) { // 保存内核的引用 this.core = core } // 某个插件方法 doSomething() { // 将内核销毁的方法 core.destroy(); }}

内核如何派生不同的权限子集呢,我们当然可以简单粗暴地使用方法透传来封装一个权限子集。但参考 VS Code 实现,我们可以使用 proxy 来实现,下面是伪代码。


export function getCore<PluginType>(): PluginType { // 创建 proxy const coreProxy = new Proxy(Object.create(null), { get(target, callee) { // 读取 sdk extend 的权限配置,如果允许调用则透传调用 if (isValidFor<PluginType>(callee) { callee.apply(core, arguments); } else { throw new Error('非法调用') } } }); return coreProxy as PluginType}



06



总结

通过对微内核架构的深入探索,结合对优秀的软件实现的分析,我们大致理解了微内核架构的高可扩展性的优势,及各种微内核的优秀实践。同时我们也在 H5 云游插件系统的架构优化方案上获得一些灵感,将当前的插件系统架构进一步完善。后续我们将把云游项目的插件系统剥离出来作为一套通用的插件系统类库,运用到其他项目当中。


感谢您的阅读,文章由 MoonWebTeam 编写。如果本文给你带来了一些启发,欢迎转发分享。


-End-
原创作者|赖文辉



对于微内核架构你有什么看法?该怎么入门微内核架构?欢迎分享。我们将选取1则最有意义的评论,送出腾讯云开发者-手机支架1个(见下图)。9月11日中午12点开奖。




您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存